/*
 * Copyright (c) 2012-2018 Red Hat, Inc.
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *   Red Hat, Inc. - initial API and implementation
 */
package org.eclipse.che.ide.api.editor.partition;

import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
import org.eclipse.che.ide.api.editor.document.DocumentHandle;
import org.eclipse.che.ide.api.editor.events.DocumentChangedEvent;
import org.eclipse.che.ide.api.editor.text.BadLocationException;
import org.eclipse.che.ide.api.editor.text.BadPositionCategoryException;
import org.eclipse.che.ide.api.editor.text.Position;
import org.eclipse.che.ide.api.editor.text.TypedPosition;
import org.eclipse.che.ide.api.editor.text.TypedRegion;
import org.eclipse.che.ide.api.editor.text.TypedRegionImpl;
import org.eclipse.che.ide.api.editor.text.rules.Token;
import org.eclipse.che.ide.util.loging.Log;

/** Default implementation of the {@link DocumentPartitioner}. */
public class DefaultPartitioner implements DocumentPartitioner {

  /** The identifier of the default partitioning. */
  public static final String DEFAULT_PARTITIONING = "__dftl_partitioning";

  private final PartitionScanner scanner;
  private final DocumentPositionMap documentPositionMap;

  private DocumentHandle documentHandle;

  /** The legal content types of this partitioner */
  private final List<String> legalContentTypes;
  /**
   * The position category this partitioner uses to store the document's partitioning information.
   */
  private final String positionCategory;

  public DefaultPartitioner(
      final PartitionScanner scanner,
      final List<String> legalContentTypes,
      final DocumentPositionMap documentPositionMap) {
    this.legalContentTypes = new ArrayList<>(legalContentTypes);
    this.scanner = scanner;
    this.documentPositionMap = documentPositionMap;
    this.positionCategory = DocumentPositionMap.Categories.DEFAULT_CATEGORY;
  }

  @Override
  public void initialize() {
    this.documentPositionMap.addPositionCategory(this.positionCategory);
    this.documentPositionMap.setContentLength(
        this.documentHandle.getDocument().getContentsCharCount());
  }

  private void updatePositions() {
    // set before the scan as the scan uses the content length
    this.documentPositionMap.setContentLength(getContentLength());
    this.documentPositionMap.resetPositions();

    Position current = null;
    try {
      Token token = scanner.nextToken();
      while (!token.isEOF()) {

        final String contentType = getTokenContentType(token);

        if (isSupportedContentType(contentType)) {
          final TypedPosition position =
              new TypedPosition(scanner.getTokenOffset(), scanner.getTokenLength(), contentType);
          current = position;
          this.documentPositionMap.addPosition(this.positionCategory, position);
        }

        token = scanner.nextToken();
      }
    } catch (final BadLocationException x) {
      Log.error(
          DefaultPartitioner.class,
          "Invalid position: " + String.valueOf(current) + " (max:" + getContentLength() + ").",
          x);
    } catch (final BadPositionCategoryException x) {
      Log.error(DefaultPartitioner.class, "Invalid position category: " + this.positionCategory, x);
    }
  }

  @Override
  public void onDocumentChanged(final DocumentChangedEvent event) {
    this.scanner.setScannedString(event.getDocument().getDocument().getContents());
    updatePositions();
  }

  @Override
  public List<String> getLegalContentTypes() {
    return new ArrayList<>(this.legalContentTypes);
  }

  @Override
  public String getContentType(final int offset) {
    final TypedPosition position = findClosestPosition(offset);
    if (position != null && position.includes(offset)) {
      return position.getType();
    }

    return DEFAULT_CONTENT_TYPE;
  }

  @Override
  public final List<TypedRegion> computePartitioning(final int offset, final int length) {
    return computePartitioning(offset, length, false);
  }

  private List<TypedRegion> computePartitioning(
      final int offset, final int length, final boolean includeZeroLengthPartitions) {
    final List<TypedRegion> result = new ArrayList<>();

    final int contentLength = getContentLength();
    try {

      final int endOffset = offset + length;

      final List<TypedPosition> category = getPositions();

      TypedPosition previous = null;
      TypedPosition current = null;
      int start, end, gapOffset;
      final Position gap = new Position(0);

      final int startIndex = getFirstIndexEndingAfterOffset(category, offset);
      final int endIndex = getFirstIndexStartingAfterOffset(category, endOffset);
      for (int i = startIndex; i < endIndex; i++) {

        current = category.get(i);

        gapOffset = (previous != null) ? previous.getOffset() + previous.getLength() : 0;
        gap.setOffset(gapOffset);
        gap.setLength(current.getOffset() - gapOffset);
        if ((includeZeroLengthPartitions && overlapsOrTouches(gap, offset, length))
            || (gap.getLength() > 0 && gap.overlapsWith(offset, length))) {
          start = Math.max(offset, gapOffset);
          end = Math.min(endOffset, gap.getOffset() + gap.getLength());
          result.add(new TypedRegionImpl(start, end - start, DEFAULT_CONTENT_TYPE));
        }

        if (current.overlapsWith(offset, length)) {
          start = Math.max(offset, current.getOffset());
          end = Math.min(endOffset, current.getOffset() + current.getLength());
          result.add(new TypedRegionImpl(start, end - start, current.getType()));
        }

        previous = current;
      }

      if (previous != null) {
        gapOffset = previous.getOffset() + previous.getLength();
        gap.setOffset(gapOffset);
        gap.setLength(contentLength - gapOffset);
        if ((includeZeroLengthPartitions && overlapsOrTouches(gap, offset, length))
            || (gap.getLength() > 0 && gap.overlapsWith(offset, length))) {
          start = Math.max(offset, gapOffset);
          end = Math.min(endOffset, contentLength);
          result.add(new TypedRegionImpl(start, end - start, DEFAULT_CONTENT_TYPE));
        }
      }

      if (result.isEmpty()) {
        result.add(new TypedRegionImpl(offset, length, DEFAULT_CONTENT_TYPE));
      }

    } catch (final BadPositionCategoryException ex) {
      Logger.getLogger(DefaultPartitioner.class.getName())
          .fine("Bad position in computePartitioning.");
    } catch (final RuntimeException ex) {
      Logger.getLogger(DefaultPartitioner.class.getName()).warning("computePartitioning failed.");
      throw ex;
    }

    return result;
  }

  @Override
  public TypedRegion getPartition(final int offset) {
    final int contentLength = getContentLength();

    List<TypedPosition> category = null;
    try {
      category = getPositions();
    } catch (final BadPositionCategoryException e) {
      Log.warn(DefaultPartitioner.class, "Invalid position cateory... with default category! ", e);
      return defaultRegion();
    }

    if (category == null || category.size() == 0) {
      return defaultRegion();
    }

    Integer index = null;
    try {
      index = this.documentPositionMap.computeIndexInCategory(positionCategory, offset);
    } catch (final BadLocationException e) {
      Log.warn(
          DefaultPartitioner.class, "Invalid location " + offset + " (max=" + contentLength + ").");
      return defaultRegion();
    } catch (final BadPositionCategoryException e) {
      Log.warn(
          DefaultPartitioner.class,
          "Invalid position cateory... with default category " + positionCategory + "!",
          e);
      return defaultRegion();
    }
    if (index == null) {
      return defaultRegion();
    }

    if (index < category.size()) {

      final TypedPosition next = category.get(index);

      if (offset == next.offset) {
        return new TypedRegionImpl(next.getOffset(), next.getLength(), next.getType());
      }

      if (index == 0) {
        return new TypedRegionImpl(0, next.offset, DEFAULT_CONTENT_TYPE);
      }

      final TypedPosition previous = category.get(index - 1);
      if (previous.includes(offset)) {
        return new TypedRegionImpl(previous.getOffset(), previous.getLength(), previous.getType());
      }

      final int endOffset = previous.getOffset() + previous.getLength();
      return new TypedRegionImpl(endOffset, next.getOffset() - endOffset, DEFAULT_CONTENT_TYPE);
    }

    final TypedPosition previous = category.get(category.size() - 1);
    if (previous.includes(offset)) {
      return new TypedRegionImpl(previous.getOffset(), previous.getLength(), previous.getType());
    }

    final int endOffset = previous.getOffset() + previous.getLength();
    return new TypedRegionImpl(endOffset, contentLength - endOffset, DEFAULT_CONTENT_TYPE);
  }

  private TypedRegionImpl defaultRegion() {
    return new TypedRegionImpl(0, getContentLength(), DEFAULT_CONTENT_TYPE);
  }

  /**
   * Returns <code>true</code> if the given ranges overlap with or touch each other.
   *
   * @param gap the first range
   * @param offset the offset of the second range
   * @param length the length of the second range
   * @return <code>true</code> if the given ranges overlap with or touch each other
   */
  private static boolean overlapsOrTouches(Position gap, int offset, int length) {
    return gap.getOffset() <= offset + length && offset <= gap.getOffset() + gap.getLength();
  }

  /**
   * Returns the index of the first position which ends after the given offset.
   *
   * @param positions the positions in linear order
   * @param offset the offset
   * @return the index of the first position which ends after the offset
   */
  private static int getFirstIndexEndingAfterOffset(
      final List<TypedPosition> positions, final int offset) {
    int i = -1;
    int j = positions.size();
    while (j - i > 1) {
      final int k = (i + j) >> 1;
      final Position p = positions.get(k);
      if (p.getOffset() + p.getLength() > offset) {
        j = k;
      } else {
        i = k;
      }
    }
    return j;
  }

  /**
   * Returns the index of the first position which starts at or after the given offset.
   *
   * @param positions the positions in linear order
   * @param offset the offset
   * @return the index of the first position which starts after the offset
   */
  private static int getFirstIndexStartingAfterOffset(List<TypedPosition> positions, int offset) {
    int i = -1;
    int j = positions.size();
    while (j - i > 1) {
      final int k = (i + j) >> 1;
      final Position p = positions.get(k);
      if (p.getOffset() >= offset) {
        j = k;
      } else {
        i = k;
      }
    }
    return j;
  }

  /**
   * Returns a content type encoded in the given token. If the token's data is not <code>null</code>
   * and a string it is assumed that it is the encoded content type.
   *
   * <p>May be replaced or extended by subclasses.
   *
   * @param token the token whose content type is to be determined
   * @return the token's content type
   */
  protected static String getTokenContentType(Token token) {
    final Object data = token.getData();
    if (data instanceof String) {
      return (String) data;
    }
    return null;
  }

  /**
   * Returns whether the given type is one of the legal content types.
   *
   * <p>May be extended by subclasses.
   *
   * @param contentType the content type to check
   * @return <code>true</code> if the content type is a legal content type
   */
  protected boolean isSupportedContentType(final String contentType) {
    if (contentType != null) {
      for (final String item : this.legalContentTypes) {
        if (item.equals(contentType)) {
          return true;
        }
      }
    }

    return false;
  }

  /**
   * Returns the position in the partitoner's position category which is close to the given offset.
   * This is, the position has either an offset which is the same as the given offset or an offset
   * which is smaller than the given offset. This method profits from the knowledge that a
   * partitioning is a ordered set of disjoint position.
   *
   * <p>May be extended or replaced by subclasses.
   *
   * @param offset the offset for which to search the closest position
   * @return the closest position in the partitioner's category
   */
  protected TypedPosition findClosestPosition(int offset) {

    int index = -1;
    try {
      index = this.documentPositionMap.computeIndexInCategory(this.positionCategory, offset);
    } catch (final BadLocationException e) {
      Log.warn(
          DefaultPartitioner.class,
          "Bad location: " + offset + "(max:" + getContentLength() + ").");
      return null;
    } catch (final BadPositionCategoryException e) {
      Log.warn(DefaultPartitioner.class, "Bad position category: " + this.positionCategory);
      return null;
    }
    if (index == -1) {
      return null;
    }

    List<TypedPosition> category = null;
    try {
      category = getPositions();
    } catch (final BadPositionCategoryException e) {
      Log.warn(DefaultPartitioner.class, "Bad position category: " + this.positionCategory);
      return null;
    }

    if (category == null || category.size() == 0) {
      return null;
    }

    if (index < category.size()) {
      if (offset == category.get(index).offset) {
        return category.get(index);
      }
    }

    if (index > 0) {
      index--;
    }

    return category.get(index);
  }

  /**
   * Returns the partitioners positions.
   *
   * @return the partitioners positions
   * @throws BadPositionCategoryException if getting the positions from the document fails
   */
  protected final List<TypedPosition> getPositions() throws BadPositionCategoryException {
    return this.documentPositionMap.getPositions(this.positionCategory);
  }

  private int getContentLength() {
    return getDocumentHandle().getDocument().getContentsCharCount();
  }

  @Override
  public DocumentHandle getDocumentHandle() {
    return documentHandle;
  }

  @Override
  public void setDocumentHandle(DocumentHandle handle) {
    this.documentHandle = handle;
  }

  @Override
  public void release() {}
}
